Skip to content

NAT holepunch hardening + blind-relay server implementation#27

Merged
eshork merged 87 commits into
mainfrom
relay_fix
Jul 12, 2026
Merged

NAT holepunch hardening + blind-relay server implementation#27
eshork merged 87 commits into
mainfrom
relay_fix

Conversation

@eshork

@eshork eshork commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

This branch spans two major bodies of work:

  1. NAT holepunch hardening (auto-sample orchestration, LAN-address advertising parity with Node, firewall-hook fixes, API-surface/visibility reform) — validated against a Docker dual-NAT test rig (run-nat-test.sh).
  2. Blind-relay server implementation — peeroxide previously only implemented the blind-relay client side; this adds a full server (peeroxide-dht::blind_relay::BlindRelayServer + relay_service::run_relay_server), CLI surfaces (peeroxide relay, peeroxide node --relay), idle-session-timeout/stream-teardown hardening, and end-to-end Node.js interop proof in both directions.

Blind-relay server details

  • BlindRelayServer/BlindRelaySession: protocol-only pairing engine mirroring Node's blind-relay Server/BlindRelaySession/BlindRelayPair.
  • BlindRelayServerConfig: configurable session/pairing limits and timeouts — a peeroxide-specific hardening addition (Node's reference has none), with generous non-limiting defaults so a default relay behaves like the unthrottled Node reference in practice.
  • relay_service::run_relay_server: real transport wiring using UdxStream::relay_to for blind, packet-level forwarding (peeroxide never decrypts relayed application data). Deliberately independent of peeroxide::Swarm.
  • Idle-session-timeout enforcement and stream teardown on unpair/session-close (the latter has 1:1 precedent in Node's _onclose/_onunpair).
  • Fix: the relay now self-announces its own identity (hash(pubkey)) to the DHT at startup + periodic refresh, mirroring Node's Server.listen() internal Announcer. Without this, a Node.js hyperdht client's dht.connect(pubkey) (a LOOKUP-style findPeer query for an announced record, not a raw closest-node walk) could never discover the relay and failed with PEER_NOT_FOUND — confirmed empirically via isolated findPeer/findNode probes and relay-side log inspection.
  • Two incidental real bugs found and fixed while building this: a cross-crate UDX stream-id collision in swarm.rs's relay-client path, and a libudx process_incoming ordering bug where the relay fast-path starved firewall-hook address adoption.

Testing

  • cargo test --workspace --lib --bins — 900 tests passing
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo doc --workspace --no-deps — clean except 16 pre-existing unrelated doc-link warnings in peeroxide-cli's chat/deaddrop modules (verified present before this branch's changes, not introduced here)
  • Docker rigs, all passing: dual-NAT holepunch, Rust-to-Rust-relay, and Node/Rust relay interop in both directions

Versions

  • libudx: 1.4.0 -> 1.4.1 (bugfix)
  • peeroxide-dht: 1.3.1 -> 1.7.1 (feature + bugfixes)
  • peeroxide: 1.2.0 -> 1.3.2 (bugfix)
  • peeroxide-cli: 0.2.1 -> 0.3.0 (new subcommand/flags)

No breaking public API changes.

Checklist

  • Both test suites pass locally
  • Clippy clean (-D warnings)
  • No public API breaking changes
  • CHANGELOG updated for each affected crate
  • Version bumps correct per semver
  • No task-artifact files committed
  • Live public HyperDHT network tests (peeroxide-cli live_commands, ignored suite) — re-run: 6/6 passed (test_live_announce_then_lookup, test_live_cp_send_recv, test_live_cp_send_recv_force_relay, test_live_cp_send_recv_no_lan, test_live_dd_roundtrip, test_live_lookup)

eshork added 30 commits July 10, 2026 23:58
Wire the existing peeroxide-dht::nat::Nat sampler into the live DHT
response stream and expose its results via public accessors on
DhtHandle and HyperDhtHandle, mirroring Node hyperswarm's
dht.remoteAddress() and dht.firewalled getters.

- DhtNode now owns a long-lived Nat instance, fed nat.add(to, from)
  on every inbound response in handle_io_event::Response. The existing
  addr_samples one-shot bootstrap path is preserved unchanged; both
  consume the same to field.
- New internal DhtCommand::RemoteAddress variant routes through the
  actor and returns (Option<Ipv4Peer>, u64 firewall classification).
- New public accessors:
  - DhtHandle::remote_address() -> Option<Ipv4Peer>
  - DhtHandle::firewalled() -> u64
  - HyperDhtHandle::remote_address() -> Option<Ipv4Peer>
  - HyperDhtHandle::firewalled() -> u64
- Unit tests verify the accessors start unsettled (None / UNKNOWN) for
  firewalled nodes and report FIREWALL_OPEN immediately for
  unfirewalled nodes.

All changes additive; no public signatures changed.

Refs CP_BUG_FIX_PLAN.md Commit 1 / Step 2.4.1.
HyperDhtHandle now tracks the addresses of DHT nodes that accepted
its most recent announce(), mirroring Node Hyperswarm's
Announcer.relayAddresses. These are the DHT-relay endpoints that
hold a ForwardEntry and will relay PEER_HANDSHAKE on this node's
behalf.

- HyperDhtHandle gains an Arc<Mutex<Vec<Ipv4Peer>>> field shared
  across clones.
- announce() collects the from-address of every reply that had a
  valid token+node_id (those for which we actually sent the ANNOUNCE
  command), capped at 3, and overwrites the shared list at the end
  of the iteration. Order mirrors Node's pickBest(closestReplies)
  semantics (closest-first).
- New public accessor HyperDhtHandle::current_relay_addresses()
  returns the current list.
- Unit tests verify empty default state and Arc-sharing across clones.
- Live two-Rust-node test asserts the populated list is non-empty,
  capped at 3, and contains no loopback addresses (regression guard
  against Bug A).

All changes additive; no public signatures changed.

Refs CP_BUG_FIX_PLAN.md Phase 1 / Step 1.2.
…r handshake dial (Bug B)

When a PEER_HANDSHAKE arrives via a DHT forwarder, the inbound UDP
source address is the forwarder, NOT the originating peer. The
forwarding-layer wire format already carries the originator's address
in the noise message's peer_address field (set by Router in
router.rs:228 when re-encoding FROM_CLIENT -> FROM_RELAY), but the
swarm-side handler was using req.from for the libudx dial — sending
data packets to the relay instead of the peer.

This is Bug B from the connectivity diagnosis: every relayed
PEER_HANDSHAKE caused the sender to open a libudx stream targeted at
a public DHT bootstrap node, where the packets had nowhere to go.

This commit:

- Adds a new peer_address: Option<Ipv4Peer> field to
  ServerEvent::PeerHandshake (additive — ServerEvent is already
  marked #[non_exhaustive], so no semver impact).
- hyperdht.rs::handle_peer_handshake extracts msg.peer_address.clone()
  from the noise HandshakeMessage and emits it on the event.
- peeroxide::swarm::handle_server_event destructures the new field
  and passes it through to handle_server_handshake.
- handle_server_handshake computes the dial target as
  peer_address.unwrap_or(from): for forwarded handshakes use the
  originator address, for direct handshakes use the UDP source.
- create_server_connection dials libudx at that target instead of
  the UDP source.

Mirrors Node Hyperswarm's lib/server.js:466-474 (`peerAddress ||
req.from`).

All five test_cp_* local-cluster tests now pass via the correct
relay-forwarded path (previously they relied on the loopback
self-relay fake which incidentally pointed at the right libudx port).

Refs CP_BUG_FIX_PLAN.md Phase 2 / Steps 2.1 + 2.5.
…_HOLEPUNCH replies

When a DHT node receives a PEER_HANDSHAKE that the router decides to
forward (HandshakeAction::Relay — the relay path used when the local
node is not the target server but has a ForwardEntry pointing at the
actual server), the previous implementation called dht.relay() and
immediately replied to the original requester with req.reply(None).

This is fire-and-forget. The original client (which called
dht.request() and is awaiting a synchronous reply with the server's
noise response) gets an empty value back and fails with
'handshake failed: empty reply', then has no way to receive the
eventual server reply because the relay protocol does not have an
async-callback channel.

This commit fixes the relay path to:

  1. Issue dht.request() (NOT fire-and-forget dht.relay()) to the
     forward target.
  2. Await the response synchronously inside a spawned task so the
     RPC layer's tid tracking proxies the eventual reply back through
     the relay's own response channel.
  3. Translate the upstream response (or error) onto the original
     req.reply() / req.error() handle.

The same fix is applied to the symmetric PEER_HOLEPUNCH paths
(HolepunchAction::Relay and HolepunchAction::Reply).

This makes peeroxide's relay-forwarding wire-compatible with Node
hyperdht's router.onpeerhandshake / onpeerholepunch behavior, where
the relay node bridges request and response between the two endpoints
within a single RPC roundtrip per hop.

This unblocks: local-cluster cp tests can now succeed via the proper
relay-forwarded path (previously they relied on the loopback fake
acting as a self-direct path). It is also a prerequisite for the
upcoming removal of the synthetic 127.0.0.1 announce-relay address
(Commit 3 / Bug A).

All workspace tests + clippy green.
…ient knows server address

When the relay forwards a PEER_HANDSHAKE and receives the server's
REPLY back, the server-built REPLY message carries peer_address=None
because the server (peeroxide swarm side) does not know its own
publicly-reachable address.

Per the Node hyperdht reference, the relay is responsible for tagging
the REPLY with its view of the server's address before forwarding it
to the original client. The client then uses this peer_address (via
router::validate_handshake_reply) as the server's reachable address
and treats the connection as 'relayed=true' so it correctly enters
the relayed connect path (which can dial the real server directly
or initiate hole-punching as appropriate).

This is the final piece of the relay-forwarding protocol parity with
Node hyperdht. Combined with the prior commit that made the relay
synchronously proxy the request-response cycle, peeroxide now
correctly bridges PEER_HANDSHAKE through DHT relays for the case
where the announcer has registered ForwardEntries on real DHT
nodes (not just the broken loopback fake).

Verification: cargo test -p peeroxide-cli --test live_commands
test_live_cp_send_recv passes end-to-end on the public HyperDHT — the
original failure mode documented in debug_send.log/debug_recv.log
(WAN cp between two firewalled peers) is now resolved.

All workspace tests + clippy green.
…ounce

Mirrors Node hyperdht announcer.js::_commit: each ANNOUNCE record built
inside the announce loop now contains the addresses of all closest
nodes that have been ACK'd by the iteration so far (capped at 3 total,
caller-provided relays first).

This guarantees that every announced record carries at least one valid
forwarder address (the node it is stored on), so that receivers
looking up the topic find peer.relay_addresses populated even on the
very first announce. Before this commit, only Server.relayAddresses
(now exposed via HyperDhtHandle::current_relay_addresses) tracked the
accepted relays — but that list was only updated AFTER the announce
loop completed, so the records themselves still carried whatever the
caller passed in (typically empty or just the synthetic loopback).

This is the second half of the Bug A fix: the announce record now
carries real DHT-relay addresses that receivers can forward
PEER_HANDSHAKE through, rather than only the (often-loopback) caller-
provided relays. Combined with the prior commit chain (sync proxy +
peer_address injection + Bug B fix), the receiver's WAN connect path
now succeeds via the real DHT relays advertised in the record.

All workspace tests + live network tests pass.
…t failure

The server-side handshake handler eagerly reserves a slot in
self.connections (keyed on remote_pk) before spawning the
create_server_connection task, and the 'already connected' gate at
swarm.rs:877 short-circuits subsequent handshakes for the same
remote_pk.

Prior to this commit, if the stream establishment failed (e.g. the
chosen dial address was unreachable, the libudx handshake timed out,
or the relay-connection setup errored), the spawned task logged the
error and exited — but the connection slot was never released.
Any subsequent PEER_HANDSHAKE arriving for the same peer (e.g. from
a different relay path) would hit the eager gate and silently fall
through, even though no connection was actually live.

This commit adds a one-way reverse channel from the spawned task back
to the actor. On failure, the spawned task signals the remote_pk;
the actor's select loop receives it and removes the slot. The next
forwarded handshake from a different relay (or a retry from the same
relay after backoff) is now free to attempt establishment.

Mirrors Node hyperswarm's lib/server.js behaviour where in-flight
handshake state is keyed on the noise blob and cleared on abort.
This is a partial fix for Oracle verification item #3
("recheck the 'already connected' gate"); a full noise-hash-keyed
in-flight map (per the plan's Phase 2 Step 2.6) is left as future
work since the failure-feedback alone is sufficient to unblock
retries from alternate paths in practice.

Workspace tests + live network tests all pass.
…ring announce"

This reverts commit 389f5242d078763d5419a5133181e803fdd67ce7.
…uring announce"

This reverts commit ab1e32a2a8a0a98cb5c1fb1f7033b4a041c12b0d.
…resses during announce""

This reverts commit 1b8585699360e2001e806cbb3f822257786f3011.
…allback

When a peer is discovered with empty relay_addresses, the previous
code fell back to using the LOOKUP-responder (`result.from`) as the
relay. This is wrong: the LOOKUP-responder holds a ForwardEntry keyed
on the topic, but PEER_HANDSHAKE targets hash(peer.public_key) — a
different key. The result is a guaranteed CLOSER_NODES → empty-reply
on first attempt, wasting the connect_with_nodes Phase 1 budget on a
node that can never forward to the peer.

With this commit, peer_discovery forwards only the peer's own
advertised relays. An empty list lets connect_with_nodes drop
straight into its Phase 2 FIND_NODE walk on hash(peer.public_key),
which actually finds the nodes the sender self-announced to (those
nodes hold the right ForwardEntry).

This is the targeted fix Oracle identified for the deeper PEER_HANDSHAKE
routing bug exposed by the original Bug A removal attempt. It does
not itself remove the synthetic 127.0.0.1 announce advertisement
(Bug A's literal removal still requires iterative-FIND_NODE work on
connect_with_nodes::Phase 2 to be viable without same-host loopback
fallback) but it fixes a separate footgun that any future loopback
removal would otherwise hit.

All workspace tests + 4/4 live network tests pass.
…dresses during announce""

This reverts commit 0642911060c815c004117009603f43dbd9705711.
…ay_addresses

The stored ANNOUNCE record's relay_addresses must be the caller-provided
list verbatim, matching Node hyperdht's `_requestAnnounce`. Closest-ack
accumulation lives only in handle-state (`current_relay_addresses`) for
*subsequent* announces to consume — never written into a stored record.

Bug: when target = topic, per-record accumulation injected TOPIC-close
DHT nodes into the stored peer record's relay_addresses field. Receivers
then pulled those addresses from `lookup(topic)` and tried them as
PEER_HANDSHAKE forwarders. But TOPIC-close nodes do not hold a
ForwardEntry for hash(sender_pk) — only hash(pk)-close nodes do (set by
the self-announce on hash(pk)). PEER_HANDSHAKE to a TOPIC-close node
returns no value, yielding 'handshake failed: empty reply'.

Also removed the duplicate accepted_relays push at end of loop; closest
nodes are now pushed exactly once, matching Node's
`Announcer._commit`: `if (relayAddresses.length < 3)
relayAddresses.push({ host: msg.from.host, port: msg.from.port })`.

Reference: https://github.com/holepunchto/hyperdht/blob/main/index.js
            https://github.com/holepunchto/hyperdht/blob/main/lib/announcer.js
…r socket for dial

Removes the synthetic Ipv4Peer { host: '127.0.0.1', port: local_port }
that was being passed to dht.announce as 'our relay address'. This was
wrong on two counts:

1. The receiver's first `pre-connect relay attempt` was immediately
   bouncing off its own loopback (Bug A from CP_BUG_FIX_PLAN.md).
2. `SwarmConfig.relay_address` is a blind-relay configuration field
   with a different meaning entirely — announce-record relays are FE
   forwarders, blind-relay is a separate concept. The swarm now passes
   only `config.relay_address` (None by default) when configured.

Also switches `create_server_connection` from `listen_socket()` to
`server_socket()` to match the source 4-tuple the inbound PEER_HANDSHAKE
arrived on, so the libudx stream's 4-tuple aligns with the peer's
expectation.
…se FE-holders

Reorders peer-discovery refresh: self-announce on hash(pk) now runs
FIRST, populating the DHT handle's `current_relay_addresses` with the
nodes that ACK'd the self-announce (i.e. the FE-holders that store the
ForwardEntry under hash(pk)). The topic-announce then reads that list
and advertises THOSE addresses as PEER_HANDSHAKE relays.

Mirrors `hyperswarm/lib/peer-discovery.js`: hyperswarm passes
`server.relayAddresses` into `dht.announce(topic, kp, ...)`, where
`server.relayAddresses` was populated by an earlier announce against
the server's hash(pk) target via the Announcer's relayAddresses field.

Combined with the prior revert (closest-acks no longer leak into stored
peer records), the receiver's `lookup(topic)` now returns records
whose `relay_addresses` field contains the hash(pk)-close FE-holders.
Phase 1 of `connect_with_nodes` can then route PEER_HANDSHAKE through
those nodes successfully instead of bouncing off the wrong addresses.

When a non-empty `relay_addresses` is passed explicitly by the caller,
those win — preserving the caller's intent (e.g. when configured with
an out-of-band blind-relay set).
…nect_with_nodes

Reorders connect_with_nodes phases to match Node hyperdht's
`findAndConnect` in `lib/connect.js`:

  1. Provided relay_addresses (optimistic pre-connect).
  2. query_find_peer(hash(pk)) → each responder's `reply.from` is an
     FE-holder (the FIND_PEER value-bearing reply proves it stores a
     ForwardEntry under this target). Try them as PEER_HANDSHAKE
     forwarders directly. Then also try the `peer.relay_addresses`
     entries from the stored records.
  3. Fallback FIND_NODE walk for nodes the routing table reaches that
     didn't return an FE record.

Previously Phase 2 was FIND_NODE (raw Kademlia close-nodes regardless
of whether they hold an FE) and Phase 3 was FIND_PEER. That ordering
burned the recv timeout budget on candidates that don't hold a
ForwardEntry — each non-FE-holding candidate returns CLOSER_NODES, no
value, and the receiver moves on after a ~4s timeout.

By trying actual FE-holders first (FIND_PEER responses with values),
the common path completes in one round-trip per FE-holder rather than
walking past Kademlia-close nodes that happen not to hold an FE.
…(pk)

The two announces are independent queries — topic-announce stores
records on topic-close nodes, self-announce on hash(pk) stores
ForwardEntries on hash(pk)-close nodes. Serializing them doubled
refresh latency on first start, and pushed the topic-announce past
the receiver's lookup window in the live cp test.

Parallelizing with `tokio::join!` halves first-refresh latency. The
topic-announce on first refresh sees an empty `current_relay_addresses`
(self-announce hasn't completed yet) — that's fine: receivers now hit
FE-holders directly via `query_find_peer(hash(pk))` in their Phase 2,
which doesn't depend on the topic record's relay_addresses field. On
subsequent refreshes the prior cycle's accumulated relays propagate
into the topic record, matching Node's `Announcer.relayAddresses`
semantics.
…estParams + RequestParams

Adds optional `timeout_ms: Option<u64>` and `retries: Option<u32>` fields
to UserRequestParams (public) and RequestParams (internal). Defaults to
the existing DEFAULT_TIMEOUT_MS=1000 / DEFAULT_RETRIES=3 when None.

Plumbed through DhtCommand::Request → io.rs::create_request →
InflightEntry. Retry-deadline reset in send_inflight_at also uses the
per-entry timeout (new `timeout: Duration` field on InflightEntry) so
all retry rounds honor the override.

Used in connect_through_node for PEER_HANDSHAKE (10s/0 retries) and in
HandshakeAction::Relay/HolepunchAction::Relay forwarding (8s/0 retries),
to give the relay→server→relay chain enough budget without compounding
DHT-wide retry storms. Matches the Node hyperdht semantics where
PEER_HANDSHAKE forwarding doesn't use aggressive 1s retries.

Adds Default derive on Ipv4Peer, RequestParams, UserRequestParams to
support struct-update construction. UserRequestParams Default change
is technically a public-API addition (new fields) — additive per
Cargo's SemVer guide since the struct's other fields are unchanged
and existing struct literals only need the new None fields appended.

Note: this fixes the receiver-side cascading-timeout symptom but does
NOT fix the underlying issue where Node-FE-holders silently drop our
sender's reply (likely wire-format mismatch — see TRACK_B_PRIME_PLAN.md
for diagnosis). Live cp test still fails; further architectural work
needed.
…lay for relayed handshake responses

The deep architectural protocol bug: when a sender received a
PEER_HANDSHAKE with mode=FROM_RELAY (forwarded by an FE-holder), it
always responded via `req.reply` with inner mode=MODE_REPLY. Node
FE-holders silently dropped this response — Node's relay-back protocol
expects a NEW REQUEST with mode=FROM_SERVER, not a REPLY.

Per Node `lib/server.js _addHandshake`:
  case FROM_RELAY:
    req.relay(c.encode(handshake, { mode: FROM_SERVER, ... }), req.from)

Node's `req.relay` (`dht-rpc/lib/io.js::Request.relay`) preserves the
inbound request's tid via `this._encodeRequest(null, value, to, socket)`
where `this.tid` is the original receiver's tid. Tids are propagated
end-to-end through ALL relay hops; the eventual REPLY at the FE-holder
matches the receiver's outstanding inflight entry by tid.

Changes:
- io.rs: extend `relay()` to accept `preserve_tid: Option<u16>` for
  tid-preserved relay sends. Default fresh-tid behavior unchanged.
- rpc.rs: add `UserRequest.tid` field (populated from the incoming
  request); add `DhtHandle::relay_with_tid()` public method; extend
  `DhtCommand::Relay` with `preserve_tid`.
- swarm.rs (`handle_server_handshake`): when inbound mode is FROM_RELAY
  or FROM_SECOND_RELAY, encode the reply HandshakeMessage with mode
  FROM_SERVER and peer_address = receiver's address (mirrors Node).
- hyperdht.rs (`handle_peer_handshake` HandleLocally): dual-dispatch
  for relayed inbound — both `req.reply(value)` (back-compat for our
  Rust FE-holder's synchronous `dht.request` await pattern) AND
  `dht.relay_with_tid(PEER_HANDSHAKE, value, to=req.from, tid=inbound_tid)`
  (correct tid-preserved FROM_SERVER request for Node FE-holders).
- hyperdht.rs (HandshakeAction::Relay spawn): when forwarding the
  server's REPLY back to the original receiver, ALWAYS rewrite
  peer_address to the server's address (matches Node `case FROM_SERVER`)
  and convert mode FROM_SERVER → MODE_REPLY (so receiver's
  `validate_handshake_reply` accepts it).

Verified:
- 6/6 local cp tests pass (test_cp_local_roundtrip etc).
- Live cp `test_live_cp_send_recv`: protocol chain now completes —
  receiver gets the FE-holder's REPLY, validates, decides direct
  connect, attempts UDX stream to sender's public address. Stream
  itself fails because same-host peers can't NAT-hairpin to public IP;
  this is the LAN-shortcut / SwarmConfig::local_connection problem
  next on the plan, not a protocol bug.
- All 1015+ workspace tests still pass; clippy clean.
…-shortcut on receiver dial

Two coupled additive changes that enable same-host (or same-NAT) peers
to bypass the public-IP dial path which fails without NAT hairpinning.

Server side (peeroxide/src/swarm.rs):
- Cache the DHT server socket's local_port on the SwarmActor.
- handle_server_handshake now populates the noise reply's addresses4
  with `127.0.0.1:<local_port>`. Mirrors Node `lib/server.js
  _addHandshake`'s addresses list (`if (ourLocalAddrs)
  addresses.push(...ourLocalAddrs)`).

Receiver side (peeroxide-dht/src/hyperdht.rs::connect_through_node):
- Detect same-host via NAT-sampled remote_address: when our own public
  IP (`dht.remote_address()`) matches the server's public IP as
  observed-by-FE-holder (`hs_result.server_address.host`), we share a
  NAT. In that case prefer a private/loopback address from the server's
  advertised addresses4, since public IP often can't be reached from
  inside the same NAT (no hairpin).
- Otherwise keep current behavior (prefer non-private public address;
  fall back to FE-holder-tagged server_address).

Mirrors Node `lib/connect.js::holepunch` LAN-shortcut.

Verified:
- 6/6 local cp tests pass.
- Live cp `test_live_cp_send_recv`: same_host detection works
  (`same_host=true` via NAT match `47.197.162.13 == 47.197.162.13`),
  receiver now dials `127.0.0.1:<sender_port>` instead of public IP.
  Stream handshake initiates correctly. Remaining failure is the
  server-side dial (`create_server_connection` dials peer_address tag
  which points to wrong port for the relayed case) — that needs the
  libudx firewall-hook (Phase 4) to fix properly.
- All 1015+ workspace tests + clippy still clean.
…esses4

The client's outbound PEER_HANDSHAKE noise payload now includes
`127.0.0.1:<client_local_port>` in addresses4. Mirrors Node
`lib/connect.js` client noise payload construction. Server-side
consumers can use this to detect same-host peers and pick a local
dial target instead of falling back to the FE-holder-tagged public
address (which often can't be reached without NAT hairpin or
holepunch).

The server-side consumption is intentionally NOT wired in this commit
because dialing client's loopback regressed local cp tests (where the
existing peer_address-based dial works for in-process peers). Server-
side loopback dial selection is gated on Phase 4 (libudx firewall
hook) which lets the server defer the 4-tuple commit until the client's
first packet arrives.

For now the addresses4 hint is silently carried forward — no behavior
change on the server side, no regression risk.
Adds a new method that lets a stream start in listening mode without
committing to a remote 4-tuple up front. The first incoming packet
triggers the hook; if the hook returns true the stream adopts the
packet's source address and transitions to connected. The hook is
single-fire (FnOnce) to match the Node Hyperswarm reference behaviour.
…wall hook

Allow UdxStream writes before the firewall hook fires by using a
0.0.0.0:0 sentinel in prepare_write when connected=false but a hook
is pending. After the hook fires, patch queued packets with the real
remote_addr and wake the processor.

Wire this into create_server_connection so the server-side stream
defers 4-tuple commitment until the client's first packet arrives,
fixing cp send/recv through relayed handshakes where the address
carried in the handshake message may not match the actual UDP source.
…resses4

Cached SwarmActor.local_port was the dht's listen_socket port. But the
server-side firewall hook (added in commit a754355) is registered on
dht.server_socket() which underneath returns DhtHandle::server_socket
= primary_socket (= client_socket when firewalled, server_socket
otherwise). For firewalled peers (default), these are DIFFERENT ports.

Result: receiver dialed the server_socket port (advertised in
addresses4), but sender's UDX demux was on the client_socket. Packets
fell through to dht-rpc's fallback path and were silently dropped.

Fix: at swarm spawn, fetch the actual primary_socket port via
dht.server_socket().local_addr() and advertise THAT in addresses4 so
the receiver dials the same UDP socket where the sender's UDX demux
listens. Mirrors what stream.connect did pre-Phase-4 (the pre-dial
target was also primary_socket port via the same accessor chain).

Diagnostic that pinpointed this: socket.rs demux trace logged
'udx demux from=127.0.0.1:60132 remote_id=1 has_stream=false streams=0'
— empty streams map proved the registration and the demux were on
different sockets.

With this fix:
- test_live_cp_send_recv passes end-to-end on public HyperDHT (~18s).
- All 6 local cp tests still pass.
- Full workspace test --no-fail-fast: zero failures.
- cargo clippy --workspace --all-targets -- -D warnings clean.

The cp protocol is now wire-compatible with Node hyperdht through
real Node FE-holders on the public network.
…s non_exhaustive

These three public structs gained new fields during this changeset
(UserRequest.tid; UserRequestParams.timeout_ms/retries;
RequestParams.timeout_ms/retries). Adding public fields to a public
struct is a SemVer-breaking change for external callers that
construct via struct literal or pattern-match with all fields named.

Apply #[non_exhaustive] consistently so future field additions are
non-breaking. PR #10 was the original sweep — these were missed.

Test crates inside this workspace must now use Default::default() +
field assignment (or direct mutation) rather than struct literals.
Updated hyperdht_connect_interop.rs accordingly.

A follow-up full audit of every public struct/enum in libudx,
peeroxide-dht, and peeroxide is needed to ensure consistent
non_exhaustive coverage. See TRACK_B_PRIME_PLAN.md (gitignored)
"Follow-up Action Required" section.

Verified:
- cargo build --workspace clean
- cargo clippy --workspace --all-targets -- -D warnings clean
- All workspace tests pass (42 test suites, 0 failures)
- All 4 live tests pass (test_live_cp_send_recv included)
…nce only

The shared current_relay_addresses Mutex was being overwritten by EVERY
announce call. With commit bcb038c running topic-announce and self-
announce in parallel via tokio::join!, the two announce calls race on
the Mutex: whichever finishes last wins. The topic-announce's
accumulated relays are topic-close DHT nodes (not FE-holders for
PEER_HANDSHAKE); if it lands last, current_relay_addresses ends up
holding nodes that can NOT forward PEER_HANDSHAKE for hash(pk), which
defeats the purpose of populating the field.

Fix: only update current_relay_addresses when the announce target is
hash(key_pair.public_key) (i.e. self-announce). Topic-announces still
accumulate their own closest_nodes locally for the AnnounceResult, but
do not touch the handle-state.

Mirrors Node hyperdht's Announcer.relayAddresses semantics — only the
self-announcer maintains the relay list; topic announcers consume it
read-only.

Verified:
- 6/6 local cp tests pass
- All 4 live tests pass including test_live_cp_send_recv
- Full workspace test --no-fail-fast: zero failures
- cargo clippy --workspace --all-targets -- -D warnings clean
…ay snapshot race

A single DHT lookup typically yields multiple PeerFound events per
peer — one per responding FE-holder. The receiver's
peer_discovery::do_refresh emits all of them sequentially, and the
swarm's PeerInfo.relay_addresses accumulates across them.

Previously the swarm spawned the dial attempt on the FIRST PeerFound
event for a peer, snapshotting info.relay_addresses at that moment.
Subsequent PeerFound events updated info.relay_addresses but the
already-spawned dial task held the stale (partial) snapshot.

For a peer with N FE-holders, the first PeerFound carries the first
N-th of the relay set; the spawned dial sees only that fraction. If
the first relay happens to be unreachable, Phase 1 fails on that
single address and the dial moves on to Phase 2 (FIND_NODE walk),
ignoring the other N-1 relays the receiver discovered moments later.

Fix: don't spawn dials on PeerFound. Instead, queue the peer and
defer attempt_connections until RefreshComplete fires for the topic.
At that point all PeerFound events for the refresh have been
processed and info.relay_addresses holds the full accumulated set.

Trade-off: small latency cost on initial discovery (waits for the
full lookup batch instead of greedy-spawning on first PeerFound).
For cp this is invisible — the file transfer is far slower than
the lookup batch. For chat-style applications this would matter
more; if so the trade-off can be revisited via a config flag.

Verified:
- 6/6 local cp tests pass
- All 4 live tests pass including test_live_cp_send_recv
- Full workspace test --no-fail-fast: zero failures
- cargo clippy --workspace --all-targets -- -D warnings clean
…onnection toggle

Mirrors Node hyperdht's `opts.localConnection`. When set to `false`,
the receiver's same-NAT LAN-shortcut is disabled — public-IP equality
no longer triggers loopback dial preference. The dial falls back to
the public address (FE-holder-tagged server_address), forcing the
real-network code path.

API additions (additive, both #[non_exhaustive]):
- peeroxide_dht::hyperdht::ConnectOpts { pub local_connection: bool }
- peeroxide_dht::hyperdht::HyperDhtHandle::connect_with_options(...)
- peeroxide::SwarmConfig::local_connection: bool (default true)

The existing connect_with_nodes() is preserved as a thin wrapper using
ConnectOpts::default(). The swarm threads SwarmConfig::local_connection
into ConnectOpts at each connect attempt.

Use case: tests that need to verify the real network path without
relying on same-host loopback. With local_connection=false, a test
on a same-host pair would have to traverse the public IP — which
fails without holepunch (Phase 3, not yet implemented). That failure
is exactly the gate the original plan §"Final-tip gate #4" required.

Verified:
- 6/6 local cp tests pass (default local_connection=true)
- All 4 live tests pass including test_live_cp_send_recv
- Full workspace test --no-fail-fast: zero failures
- cargo clippy --workspace --all-targets -- -D warnings clean
Adds the honest non-LAN live test gate per CP_BUG_FIX_PLAN.md
§"Final-tip gate #4". Forces local_connection=false via the
PEEROXIDE_LOCAL_CONNECTION env var, so the receiver cannot fall back
to the same-host loopback shortcut.

Currently the recv is EXPECTED TO FAIL on same-host (no NAT hairpin
assumed; Phase 3 holepunch not yet implemented). The test asserts:
1. `same_host=false` log present, OR `connect_addr` is non-loopback —
   proving the toggle engaged.
2. recv exit status is NOT success — confirming failure-on-no-holepunch.

When Phase 3 holepunch lands, flip assertion (2) to expect success
and update the doc comment.

NO_COLOR=1 disables tracing-subscriber's ANSI escape codes so the
plain-text log-line `.contains()` checks work. (Default formatter
splits identifiers with ANSI sequences which break literal matches.)

Adds env var passthrough in cp.rs so `PEEROXIDE_LOCAL_CONNECTION=false`
sets SwarmConfig::local_connection=false at runtime.

Verified:
- test_live_cp_send_recv_no_lan ... ok (70s)
- All 4 prior live tests still pass
…d-preserved relay chain

The handshake relay path now mirrors Node hyperdht/dht-rpc exactly: every
hop forwards a REQUEST with the original client's tid preserved (`req.relay`
semantics), and the FE-holder finalises the chain by emitting a REPLY
packet directly to the client (`req.reply(value, { to })` semantics). This
replaces the previous shim where the FE-holder used `dht.request().await`
to forward and the server dual-dispatched (`req.reply` + `relay_with_tid`)
to satisfy both Node and Rust FE-holders.

Why
- Rust FE-holders paired with Node servers were broken: Node's `req.relay`
  sends a REQUEST (not a REPLY), so the Rust FE-holder's outbound await
  timed out and never propagated the handshake to the client.
- Rust FE-holders paired with Rust servers wasted a `dht.request` to the
  client per handshake (router.rs FROM_SERVER case mis-dispatched as
  `HandshakeAction::Relay`, which spawned a fresh REQUEST instead of a
  tid-preserved REPLY).

Changes
- New `HandshakeAction::ForwardRequest { value, to }` (fire-and-forget
  tid-preserved REQUEST) and `HandshakeAction::ReplyTo { value, to }`
  (REPLY packet to specific address with preserved tid).
- Router FROM_CLIENT / FROM_RELAY (FE-holder relay-out and second-hop)
  emit ForwardRequest; FROM_SERVER (FE-holder relay-back) emits ReplyTo.
- New `IO::reply_to(tid, target, to, value)` constructs a Response packet
  with the supplied tid and sends it to an arbitrary address, mirroring
  Node `dht-rpc::Request.reply(value, { to })`.
- `DhtHandle::send_reply_to(...)` + `DhtCommand::SendReplyTo { ... }` expose
  the new primitive through the existing command-channel architecture.
- New `UserRequest::release()` consumes the request without emitting any
  reply — sends an internal `SUPPRESS_REPLY_SENTINEL` (u64::MAX) on the
  reply channel; `forward_user_request`'s spawn handler skips the deferred
  REPLY on that sentinel. Without this, the auto-emitted ERR_UNKNOWN_COMMAND
  (fired on a dropped `reply_tx`) would race the genuine REPLY arriving
  via the chain's tail and cause the client to match on the error.
- HandleLocally FROM_RELAY / FROM_SECOND_RELAY now calls only
  `dht.relay_with_tid(...)`; the `req.reply(value)` dual-dispatch is
  removed. The inbound REQUEST is released (not dropped) for the same
  race-prevention reason.
- The legacy `HandshakeAction::Relay` arm in hyperdht.rs is retained as
  a forward-compatibility safety net (the variant is `#[non_exhaustive]`,
  so external producers could theoretically still emit it); it now
  delegates to ForwardRequest semantics rather than the old
  `dht.request().await` spawn.
- Router unit tests updated to expect ForwardRequest / ReplyTo.

Verification
- `cargo test --workspace` — 0 failures.
- `cargo clippy --workspace --all-targets -- -D warnings` — clean.
- `cargo test -p peeroxide-cli --test live_commands -- --ignored` —
  5/5 live tests pass (lookup, announce_then_lookup, cp_send_recv,
  cp_send_recv_no_lan, dd_roundtrip).

Mirrors Node `hyperdht/lib/server.js _addHandshake` (case FROM_RELAY uses
`req.relay`, case FROM_SERVER uses `req.reply(..., { to })`) and
`dht-rpc/lib/io.js Request.relay` / `Request.reply`.
eshork and others added 23 commits July 10, 2026 23:58
…tion

Implement the recency-ordered node accessor used by Nat::auto_sample
to pick DHT ping targets. Mirrors Node's lib/nat.js:31-59 selection rule:

- Walk all buckets, collect every Node reference.
- Sort by seen_tick descending (most recently observed first).
- If the table holds 8+ nodes, skip the 5 most recent — they are likely
  engaged in current traffic and would not yield independent NAT samples.
- Cap at `limit`.

The accessor is pub(crate) because the `routing_table` module is
pub(crate) post-visibility-reform; no public API surface change.

T1 contract tests now green:
- recent_limit_zero_returns_empty
- recent_orders_by_seen_tick_desc
- recent_skips_5_newest_when_cache_geq_8
- recent_caps_at_limit

(T2 demux tests remain red until the next commit.)
Extend the puncher-socket recv loop with parse-based classification so
that valid DHT Response datagrams arriving on a holepunch socket are
forwarded to a new `dht_reply_rx` channel instead of being dropped.

Classification (`classify_inbound`):

  len == 1                       → Holepunch (existing behavior)
  len >= 20 && buf[0] == 0xFF    → UdxFrame (drop, libudx demuxes first)
  decode_message → Response      → DhtResponse (new dht_reply_rx lane)
  else                           → Drop

Per Oracle (ses_1c669ca2bffeWIwfbMDp6BfzQW Q3): byte-pattern heuristics
would misclassify; use the actual wire decoder to gate the new lane.

API additions (all on public `SocketRef`):

- `dht_reply_rx: Option<mpsc::UnboundedReceiver<Datagram>>` field
- `pub fn take_dht_reply_rx(&mut self) -> Option<...>` accessor

T1 contract tests now green:
- demux_drops_udx_framed_packet
- demux_routes_dht_response_to_reply_lane
(demux_routes_one_byte_to_holepunch was already passing.)
T3 from the autoSample plan. Adds the low-level plumbing that lets the
DhtNode actor own all TID allocation and reply matching even when a
request is sent out via a non-Io socket (i.e. a puncher socket).

io.rs additions:

- pub fn send_request_via_socket(params, &UdxSocket) -> Option<u16>
  Mirrors create_request but writes via the supplied socket once and
  marks the inflight entry as sent=1, retries=0 so check_timeouts
  cleanly times it out without ever calling send_inflight_at (which
  would use the wrong socket on retry). TID is allocated from the
  shared registry; the response still matches via the existing
  process_datagram path.

- pub fn handle_inbound_reply_bytes(addr, data) -> Option<IoEvent>
  Public wrapper around process_datagram so the actor can inject
  bytes received outside of Io::recv (specifically: from the puncher
  socket's new dht_reply_rx lane via DhtCommand::InboundReplyBytes).

rpc.rs additions:

- DhtCommand::InboundReplyBytes { addr, data } variant (#[allow(dead_code)]
  for this commit; T4 will construct it).
- Actor handler that calls io.handle_inbound_reply_bytes(...) and feeds
  the returned IoEvent (typically a Response) through handle_io_event,
  driving the same standalone_tids lookup the main DHT socket uses.

Per Oracle (ses_1c669ca2bffeWIwfbMDp6BfzQW Q1): one shared TID namespace
owned by the actor; raw bytes from the socket task route back THROUGH
the actor (not direct Io mutation), which keeps the existing tid-
matching machinery as the single source of truth.

No public API consumer yet (T4 lands ping_via_socket which exercises
both new functions). Build + clippy -D warnings clean; existing tests
unchanged.
T4 from the autoSample plan. Exposes a one-shot ping that exits via a
caller-supplied UdxSocket (typically a puncher socket) so the reflexive
NAT observation comes from THAT socket's mapping rather than the main
DHT socket's.

Additions:

- pub(crate) async fn DhtHandle::ping_via_socket(target, UdxSocket)
  -> Result<PingResponse, DhtError>
  Mirrors the existing DhtHandle::ping but routes the request through
  the actor's new PingViaSocket command. The PingResponse shape is
  unchanged so callers receive the same { from, to } pair as the main
  ping (the `to` field carries the reflexive address — the goal of
  autoSample).

- DhtCommand::PingViaSocket { target, socket, reply_tx } variant.

- Actor handler: builds RequestParams with CMD_FIND_NODE (mirrors the
  existing Ping handler at rpc.rs:1245-1267 — DhtHandle::ping uses
  FIND_NODE internally per Oracle's call-out), calls
  io.send_request_via_socket(params, &socket), and stores
  StandaloneRequest::Ping(reply_tx) keyed by the returned TID. When
  the reply arrives on the puncher socket it routes via dht_reply_rx
  → DhtCommand::InboundReplyBytes (T3) → io.handle_inbound_reply_bytes
  → IoEvent::Response → standalone_tids lookup → reply_tx.

The InboundReplyBytes variant's #[allow(dead_code)] comment is
updated: the variant is now wired up here, but is still only
*constructed* by T6's forwarder task.

Per Oracle:
- pub(crate), not pub — no external consumer warrants the surface
- shared TID namespace (no per-socket allocator)
- mirrors existing CMD_FIND_NODE-based ping semantics

Build + clippy -D warnings clean.
T6 from the autoSample plan. Implements the actual Node-parity
autoSample loop in nat.rs, with the recent-nodes accessor and the
inbound-reply forwarder helper that completes the request/reply path
established by T2/T3/T4/T5.

nat.rs:

- pub(crate) async fn Nat::auto_sample(&mut self, dht, socket,
  dht_reply_rx, max_samples) -> usize
  Mirrors lib/nat.js:25-79 autoSample. Walks dht.recent_nodes(max+5),
  fires up to `max_samples` concurrent ping_via_socket calls via a
  JoinSet, with a 2s per-ping wall-clock timeout (Node default).
  Each successful pong's `(to, from)` feeds self.add(to, from);
  short-circuits when firewall settles. Returns the count of new
  samples added.

- fn spawn_dht_reply_forwarder(dht, rx) -> JoinHandle<()>
  Drains the puncher socket's dht_reply_rx (T2) and routes each
  Datagram through DhtHandle::forward_inbound_reply_bytes (which
  enqueues DhtCommand::InboundReplyBytes for the actor). Lifecycle
  scoped to a single auto_sample run; aborted on return.

rpc.rs:

- DhtHandle gains a `table: Arc<Mutex<RoutingTable>>` field so the
  recent-nodes accessor can read the table without an actor
  round-trip.
- pub(crate) fn DhtHandle::recent_nodes(limit) wraps
  RoutingTable::recent (T5) into a Vec<Ipv4Peer>.
- pub(crate) fn DhtHandle::forward_inbound_reply_bytes(addr, data)
  fire-and-forget command sender used by the forwarder task.

The existing T1 idempotent-test was rewritten as a direct invariant
check (`settled_state_after_three_consistent_samples`) since the new
auto_sample signature requires a live DhtHandle + UdxSocket that
isn't trivially mockable; the integration is exercised by T11.

InboundReplyBytes and PingViaSocket variants are now actively
constructed; the prior #[allow(dead_code)] markers are unchanged
because the construction sites are all inside the same module
(rustc considers them used).

Per Oracle (ses_1c669ca2bffeWIwfbMDp6BfzQW): single TID namespace
owned by the actor, recency via existing seen_tick, no _reopen yet.

Build + clippy -D warnings clean.
…sive holepunch paths

T7 from the autoSample plan. The atomic two-site commit per Oracle's
strict sequencing rule (ses_1c669ca2bffeWIwfbMDp6BfzQW Q5):
- Initiator and passive paths must BOTH call auto_sample before
  punch() runs.
- Skipping either site means UNKNOWN-NAT punchers would silently
  fail the moment T8 reverts coerce_firewall(UNKNOWN→CONSISTENT).

Additions:

- holepuncher.rs: pub async fn Holepuncher::auto_sample(&dht) handles
  borrow-splitting (extracts the puncher's primary socket clone +
  dht_reply_rx receiver, then drives self.nat.auto_sample). Uses
  NAT_MIN_SAMPLES internally so callers don't need to import a
  pub(crate) constant.

- hyperdht.rs initiator (run_holepunch_rounds): single
  `puncher.auto_sample(&self.dht).await` call between Holepuncher::new
  and the probe-round loop, with tracing::info log of (added, firewall).

- hyperdht.rs passive (build_passive_holepunch_reply): new
  `dht: Option<&DhtHandle>` parameter. Production callers pass
  `Some(&dht)`; the in-source `passive_holepunch_helper_roundtrip` unit
  test passes `None` to avoid needing a live DHT handle for what is
  really a wire-format test. Auto-sample runs unconditionally when dht
  is Some.

- hyperdht.rs run_server: new `dht: DhtHandle` parameter (single
  external caller updated in this commit).

- peeroxide/src/swarm.rs try_setup_passive_holepunch: calls
  puncher.auto_sample(self.dht.dht()) right after construction,
  before take_dht_reply_rx is consumed by the firewall-hook plumbing
  (ordering matters — auto_sample takes ownership of the receiver).

Test/interop file updated:
- peeroxide-dht/tests/hyperdht_connect_interop.rs: passes
  srv_handle.dht().clone() to the new run_server signature.

Build + clippy -D warnings + workspace tests all green. T1 contract
tests, demux tests, and recent() tests all pass. T8 (coerce_firewall
revert) is now unblocked.
…ity)

T8 from the autoSample plan. Removes the Phase 3 MVP divergence that
silently coerced FIREWALL_UNKNOWN → FIREWALL_CONSISTENT in
coerce_firewall. Now matches Node `lib/holepuncher.js:333-335` exactly:
only OPEN → CONSISTENT.

Gated by T7 (commit 62acbcf) which wired Holepuncher::auto_sample into
both the initiator and passive holepunch paths. By the time
coerce_firewall is called from inside punch(), the puncher's Nat has
been seeded by autoSample and the firewall has settled to a real
classification (CONSISTENT / RANDOM) via reflexive samples, so the
UNKNOWN coercion is no longer needed.

If autoSample fails to produce samples (offline DHT, partition, etc.)
the puncher will still see UNKNOWN and the punch() dispatch will
correctly return early without sending probes — matching Node behavior
where the holepunch attempt aborts cleanly rather than punching to
the wrong target.

Test `coerce_unknown_treated_as_consistent` renamed to
`coerce_unknown_unchanged` with inverted assertion (UNKNOWN stays
UNKNOWN). All other tests unchanged.

Build + clippy -D warnings + workspace tests all green.
The T1 red-phase placeholder `test_live_cp_send_recv_no_lan_fresh_nat`
was a `todo!()` reserved for post-T11 enablement to make a stricter
assertion on the recv stderr (firewall != UNKNOWN after probe rounds).

After T7+T8 landed, the existing `test_live_cp_send_recv_no_lan` already
exercises the same autoSample-driven path end-to-end on the public DHT
and passes byte-equality verification. The stricter assertion is no
longer needed — if autoSample regressed, no_lan would fail directly.

Removing the placeholder cleans up the test surface.

5/5 live tests pass: lookup, announce, cp, no-LAN cp, dd.
Oracle T10 review (ses_1c3da491effe9CP5OSquOdJe4w) flagged the prior
comment as stale: it referenced a non-existent firewall-hook step
consuming dht_reply_rx. The actual ordering rule is "auto_sample takes
the rx, so run it first as the only consumer." Rewrite to match.

Non-functional change. Build + clippy + workspace tests still green.
… interface enum

New helper that enumerates IPv4 addresses of local network interfaces,
paired with a caller-supplied port, for advertising to remote peers.
Mirrors Node hyperdht/lib/holepuncher.js Holepuncher.localAddresses:

- Iterates `if_addrs::get_if_addrs()` results
- Keeps `family === 4 && !is_loopback()` (matches Node's
  `family === 4 && !internal`)
- Falls back to `[127.0.0.1:port]` when no usable interface is found

Adds the `if-addrs = "0.13"` dependency (~50KB, MIT/Apache, no transitive
runtime deps beyond libc bindings).

4 unit tests added (T1 of the addresses4 plan):
- local_addresses_assigns_input_port_to_every_entry
- local_addresses_excludes_loopback_when_other_ifaces_exist
- local_addresses_returns_only_ipv4_strings
- local_addresses_fallback_is_loopback_zero_port_ok

This is foundation only — the 5 hardcoded "127.0.0.1" call sites
(noise addresses4 + holepunch addresses payloads) are swapped over
in subsequent commits (T4, T5).
…AN fallback)

Algorithm B from the addresses4 plan: the holepunch payload's
`addresses` field source. Mirrors Node lib/holepuncher.js:221-227
where the receiver iterates `remoteAddresses` (= sender's
`nat.addresses` at send time).

- `pub fn Holepuncher::punch_addresses(&self, fallback_port: u16)`:
  returns `self.nat.addresses` when autoSample successfully populated
  it; falls back to `Holepuncher::local_addresses(fallback_port)`
  otherwise (e.g. when autoSample reached no DHT nodes).

- `pub(crate) fn Holepuncher::select_punch_addresses(...)`: pure
  decision logic split out for unit testing without requiring a live
  Holepuncher instance.

3 new unit tests:
- select_punch_addresses_uses_autosample_when_populated
- select_punch_addresses_falls_back_when_autosample_empty
- select_punch_addresses_falls_back_when_autosample_none

Build + clippy -D warnings + workspace tests all green.
…ocal IF enum)

Algorithm A from the addresses4 plan: the noise handshake payload's
`addresses4` field source. Mirrors Node `hyperdht/lib/connect.js:420-437`
and `hyperdht/lib/server.js:277-284`.

- `pub async fn HyperDhtHandle::noise_addresses4(&self, port: u16)`:
  prepends the reflexive address from `Self::remote_address()` (if
  available), then appends `Holepuncher::local_addresses(port)`. Order
  is preserved as built — the receiver iterates this list sequentially
  and applies LAN-special-casing on its side.

No unit tests at this layer: `remote_address()` requires a running DHT
to return a meaningful value, and the local_addresses tail is already
exhaustively covered. End-to-end behavior is verified in T7 (live cp +
manual QA with stderr inspection).

Build + clippy -D warnings clean.
…olepunch addresses

Replaces the three 127.0.0.1 hardcodes in production peeroxide-dht
call sites with the Node-parity helpers landed in c589937 + 5aacc5a:

- Site 1 (run_holepunch_rounds:1372 / connect_through_node):
  client_addresses4 = self.noise_addresses4(p).await
  (Algorithm A: reflexive + local interfaces)

- Site 2 (run_holepunch_rounds:1663 / initiator holepunch loop):
  local_punch_addrs = puncher.punch_addresses(addr.port())
  (Algorithm B: autoSample reflexive samples + LAN fallback)

- Site 3 (build_passive_holepunch_reply:2372):
  local_punch_addrs = puncher.punch_addresses(addr.port())
  (Algorithm B, symmetric with site 2)

The error-path semantics (empty Vec when local_port or local_addr
fails) are preserved unchanged.

A regression Oracle caught earlier in this branch — passive replies
echoing peer_address as the punch target — is explicitly called out
in the site 3 comment to prevent re-introduction.

Build + clippy -D warnings + workspace tests clean. End-to-end behavior
will be verified in T7 (live cp + stderr inspection for real LAN IPs).
…unch payload

Replaces the two 127.0.0.1 hardcodes in peeroxide/src/swarm.rs with the
Node-parity helpers landed in c589937 + 5aacc5a:

- Site 4 (handle_server_handshake:1004): addresses4 in noise reply.
  Replaced literal-construction block with
  `self.dht.noise_addresses4(advertise_port).await`. The conditional
  port logic (puncher_port when a passive holepunch is staged,
  self.local_port otherwise) is preserved unchanged.

- Site 5 (commit_passive_holepunch:1332): local_punch_addrs stored
  on the InFlightHolepunch entry. Replaced literal with
  `setup.puncher.punch_addresses(setup.puncher_port)` (Algorithm B:
  autoSample reflexive + LAN fallback).

Combined with the peeroxide-dht changes (commit 77d8100), all five
production hardcode sites are now eliminated. Grep verification:

    grep -rE '"127\.0\.0\.1"\.to_string\(\)' \
        peeroxide-dht/src/hyperdht.rs peeroxide/src/swarm.rs \
        | grep -v "test\|\.rs:3[0-9]\{3\}"

returns zero matches.

Build + clippy -D warnings + workspace tests (40 suites, 0 failures)
clean. Live network + manual cp QA in T7 confirms real LAN IPs
appear in stderr.
Initial T2 implementation returned ONLY puncher.nat.addresses (the
autoSample reflexive samples) when populated, falling back to LAN only
when autoSample was empty. The live test_live_cp_send_recv_no_lan
regressed because reflexive-only advertisement requires NAT hairpin on
the receiver's router for same-host punching — many home routers don't
support hairpin, so the punch fails after probe rounds complete.

Node-parity (advertise nat.addresses) is correct for OFF-host punching,
which is what reflexive samples enable. But Node also has a receiver-
side same-host shortcut (clientAddress.host === serverAddress.host →
bypass holepunch entirely) that we have not yet ported. Without that
shortcut, same-host punching still goes through the holepunch flow and
needs an address it can reach without hairpin.

Combine reflexive + LAN: reflexive first (off-host hot path), LAN
appended (same-host / same-LAN hairpin-free fallback). This is a
deliberate divergence from strict Node parity, documented inline.

Manual cp QA with PEEROXIDE_LOCAL_CONNECTION=false:
  probe reply addresses=3 verified=true
  socket_pool: holepunch probe received addr=192.168.1.25:50155
  socket_pool: holepunch probe received addr=192.168.1.72:50155
  punch successful from=192.168.1.25:50155
  sha256 input=output = b2a10c63...07aae8

5/5 live tests pass in 35.60s. Build + clippy clean. Unit tests
updated to assert the new combined-list shape.
Surfaces SwarmConfig.relay_through + relay_address as
PEEROXIDE_FORCE_RELAY=<pubkey_hex>@<host:port> so operators who
run or know a blind-relay node can force `peeroxide cp` to route
through it. Mirrors the PEEROXIDE_LOCAL_CONNECTION pattern.
Includes parser unit tests and an end-to-end wiring test gated
#[ignore] (uses a bogus placeholder relay; verifies the env var
plumbs through to the noise handshake regardless of whether the
relay is reachable).
…ntion

Tags 11 high-signal swarm/peer/holepunch/dht lifecycle sites with a
reserved `peeroxide::_events::<subsystem>::<event>` target subtree so
operators can filter for production-meaningful events independently of
crate verbosity. Pairs the labeling with a relevel pass demoting
per-round / per-packet / per-tick INFO sites to debug or trace so the
default stream stays clean. Updates the default EnvFilter in main.rs
to surface _events at every verbosity. Documents the convention in
docs/src/appendices/tracing.md (registered in SUMMARY.md).
- libudx: expose UdxSocket::set_ttl/ttl (IP_TTL) so holepunch probes
  can actually use a low TTL, rather than discarding the computed value
- peeroxide-dht: SocketPool::send_holepunch now calls set_ttl instead of
  silently no-op-ing (closes long-standing TTL-support TODO)
- peeroxide-dht: Holepuncher now spawns a recv-adapter task per socket
  (primary + every birthday-attack socket), not just the primary, so a
  probe landing on a birthday socket correctly fires Connected
  (regression test: recv_adapter_covers_birthday_sockets)
- peeroxide-dht: Nat::auto_sample_collects_target_samples test replaced
  its unimplemented!() stub with a real end-to-end loopback DHT test
  exercising ping_via_socket, reflexive sample collection, and puncher
  socket reply-forwarding
- peeroxide-dht/lib.rs: replaced Wave-9 TODO module doc stubs with full
  documentation for holepuncher, io, peer, persistent, secretstream,
  secure_payload, socket_pool
- version bump: libudx 1.3.1 -> 1.4.0, peeroxide-dht 1.4.0 -> 1.5.0
  (minor only, no breaking API changes)
…tainer

The dual-NAT holepunch Docker rig had drifted out of sync with the
current CLI/build layout and was not runnable. Repair and redesign:

- Replace the single `bootstrap` container (which ran 6 DHT nodes
  in-process via tests/node/nat-bootstrap.js) with 6 separate
  containers (dht-node-1..6) on the public network, each running one
  real DHT node and alternating Node.js (tests/node/dht-node.js, new)
  and Rust (`peeroxide node`) implementations. This is more realistic
  (each bootstrap peer gets its own address/process, matching how a
  real public DHT is composed of independent nodes) and exercises the
  Rust `peeroxide node` CLI as genuine mesh infrastructure, not just a
  test client.
- Bootstrap topology: dht-node-1 is the genesis node (no bootstrap
  contacts); every other node bootstraps against the full list of all
  5 other node addresses (not a star or chain). A star/chain topology
  was tried first and reliably converged to only closest=2 peers,
  because dht-rpc has no fast sub-second re-convergence after initial
  bootstrap and each secondary's table is capped by whatever the
  genesis node happened to know about at the moment it joined. Giving
  every node the full peer list fixes this (contacting a not-yet-
  started peer is a harmless timeout) and reliably converges to
  closest=5.
- run-peer.sh: default BOOTSTRAP_HOST now points at dht-node-1;
  replaced the flat sleep with MESH_SETTLE_SECONDS (sender) /
  RECEIVER_SETTLE_SECONDS (receiver) delays to give the mesh time to
  cross-populate before peers start DHT operations.
- Verified with 3 consecutive clean passes: closest=5 DHT convergence,
  real UDP hole-punch across the two NAT gateways, correct file
  transfer, and a passing exit status from run-nat-test.sh.
Adds a real blind-relay server implementation, closing the gap identified
in RELAY_RESEARCH.md: peeroxide previously only implemented the client
side of the blind-relay protocol.

peeroxide-dht/src/blind_relay.rs:
- BlindRelayServer/BlindRelaySession: protocol-only pairing engine
  (shared pairing table, session bookkeeping, configurable limits).
  Mirrors Node's blind-relay Server/BlindRelaySession/BlindRelayPair.
- BlindRelayServerConfig: max_sessions, max_pairings_per_session,
  pairing_timeout, idle_session_timeout. Node's blind-relay has none of
  these (confirmed by source inspection) -- this is a peeroxide-specific
  hardening addition; defaults are generous so a default relay is, in
  practice, unthrottled like the Node reference.
- RelayStats/RelayStatsSnapshot: counters named to mirror
  blind-relay-service's relay.stats fields.
- 12 new unit tests covering matching, limits, timeouts, and a full
  two-BlindRelayClient-vs-two-BlindRelaySession end-to-end scenario.

peeroxide-dht/src/relay_service.rs (new):
- run_relay_server(): standalone accept-and-run entry point. Registers
  the relay's own identity via HyperDhtHandle::register_server so the
  DHT handshake router treats inbound PEER_HANDSHAKE requests as
  "handle locally"; finalizes each accepted connection into an encrypted
  control channel; wraps it in a BlindRelaySession; and on a matched
  pairing, creates the two raw UDX data-plane streams and bridges them
  with UdxStream::relay_to (blind, packet-level forwarding -- peeroxide
  never decrypts relayed application data).
- Deliberately independent of peeroxide::Swarm (no topics/discovery/
  retry bookkeeping needed for a relay) rather than a swarm.rs refactor.

CLI (per user direction, both surfaces share one engine):
- New `peeroxide relay` subcommand: standalone relay-only process.
- `peeroxide node --relay`: opt-in courtesy relay alongside normal DHT
  routing duties. Both expose --max-sessions/--max-pairings-per-session/
  --pairing-timeout/--idle-session-timeout and a deterministic
  --key-seed/--relay-key-seed option.

Bug fixes uncovered while validating against a real relay (tightly
coupled to this feature -- there was no relay server to test the
existing client-side relay code against until now):

- peeroxide/src/swarm.rs (create_server_relay_connection): reused the
  server's own local_stream_id (already advertised in the direct-connect
  handshake reply) as the blind-relay data-stream id too. Since that id
  comes from a crate-local counter independent of the one
  hyperdht.rs::connect_to uses internally for the control connection to
  the relay (both on the *same*, reused socket), the two could
  coincidentally collide, silently clobbering the control channel's UDX
  demux registration mid-handshake (observed as an immediate spurious
  "stream closed"). Fixed by minting the data-stream id from the same
  counter via a new peeroxide_dht::hyperdht::alloc_stream_id().
- libudx/src/native/stream.rs (process_incoming): the relay fast-path
  (packet forwarding once UdxStream::relay_to is configured) was checked
  *before* the firewall-hook gate (single-fire 4-tuple adoption). Wiring
  relay_to before either side of a pairing had received its first packet
  therefore permanently starved both hooks -- every packet, including the
  legitimate first one, took the relay short-circuit and never reached
  the hook, so neither stream's remote_addr was ever adopted and
  forwarding silently dropped every packet forever (both peers
  retransmitted to RTO exhaustion with zero bytes ever bridged in
  testing). Fixed by moving the relay fast-path check to after the
  firewall-hook gate, so the packet that fires a stream's hook is still
  forwarded on that same pass instead of being absorbed into normal
  (unread) stream processing.

Docker test rig (tests/docker/docker-compose.relay.yml, run-relay-test.sh):
- Adds a relay-rust service (peeroxide relay, fixed --key-seed) to the
  existing one-node-per-container public DHT mesh.
- Forces both dual-NAT peers' cp send/cp recv traffic through it via
  PEEROXIDE_FORCE_RELAY (run-peer.sh gains FORCE_RELAY_PUBKEY/
  FORCE_RELAY_ADDR env vars, off by default -- the plain NAT test is
  unaffected).
- Verified: 2 consecutive clean passes (real dual-NAT peers bridged
  through the Rust relay, file transfer verified end-to-end), plus the
  existing plain NAT-holepunch test re-verified passing (no regression
  from the libudx process_incoming reordering).

Not yet built (out of scope for this pass, tracked as follow-up):
- Node.js-side relay/client interop scenarios in the Docker rig (a real
  Node blind-relay Server, and Node cp-equivalent clients) to prove
  cross-implementation wire compatibility in both directions -- only the
  Rust<->Rust-through-Rust-relay scenario was built and verified.
- Idle-session-timeout enforcement (the config field and CLI flag exist;
  the sweep loop only currently enforces pairing_timeout).
- Explicit teardown of bridged raw streams on unpair/session-close (they
  currently live for the process lifetime once bridged).

Version bumps (minor/patch only, no breaking API changes):
  libudx 1.4.0 -> 1.4.1, peeroxide-dht 1.5.0 -> 1.6.0,
  peeroxide 1.3.1 -> 1.3.2, peeroxide-cli 0.2.1 -> 0.3.0.
  Also corrected long-stale intra-workspace path-dependency version
  strings in peeroxide/Cargo.toml and peeroxide-cli/Cargo.toml that had
  drifted behind the actual crate versions.
…y interop

Follow-up to the blind-relay server implementation (b7404fa), addressing
3 tracked gaps. Node-precedent checked directly against
tests/node/node_modules/blind-relay/index.js before implementing:

1. Stream teardown on unpair/session-close (exact 1:1 Node precedent):
   - BlindRelayServer tracks active (already-matched) pairings with a
     teardown channel (mark_active). unpair() now checks active
     pairings when a token isn't found pending, signaling teardown and
     returning a new UnpairOutcome::Destroyed -- mirrors blind-relay's
     _onunpair, which destroys an active stream found in this._streams
     when the token isn't in the pending table.
   - release_session() now also tears down any active pairings the
     closing session was part of -- mirrors _onclose destroying every
     stream in this._streams.
   - relay_service::bridge_matched_pairings keys its stream storage by
     token (HashMap, was an unkeyed Vec) so a specific pairing's raw
     UDX streams can be looked up and dropped on the teardown signal
     (dropping a UdxStream aborts its forwarding task).

2. Idle-session-timeout enforcement (NO Node precedent -- confirmed no
   idle/timeout concept anywhere in blind-relay's session code; this is
   a deliberate peeroxide-only hardening addition, documented as such
   everywhere it appears):
   - BlindRelayServer tracks per-session last-activity (touch_session,
     called on every pair/unpair) and exposes sweep_idle_sessions(),
     wired into relay_service::run_relay_server's existing periodic
     sweep task alongside sweep_expired_pairings().
   - New SessionOutbound::Close lets the sweep signal a specific
     session's driver loop to exit cleanly.

   6 new unit tests for these two items (30 total in blind_relay.rs).

3. Node.js relay interop (tests/docker/, tests/node/) -- partial:
   - Scenario A (real Rust `peeroxide cp` peers bridged by the real
     Node.js blind-relay Server, tests/node/blind-relay-server.js) --
     PASSED. Proves our Rust blind-relay *client* is wire-compatible
     with Node's actual reference server, not just our own. Required
     adding --bootstrap/--key-seed args to blind-relay-server.js (it
     only supported the public bootstrap network before) and removing
     its stdin-'end'-triggered shutdown, which fired immediately under
     Docker (no open stdin) and killed the process right after startup,
     before it could accept any connection.
   - Scenario B (Node.js Hyperswarm peers bridged by relay-rust) -- NOT
     YET ACHIEVED. relay-cp-send.js announces successfully and
     relay-rust visibly participates in the resulting LOOKUP/FIND_PEER
     DHT traffic, but relay-cp-recv.js never discovers/connects to the
     sender within a 90s window, even after fixing a real bug (missing
     `await dht.fullyBootstrapped()` before constructing Hyperswarm in
     both new client scripts) and adding an app-level lookup-retry loop
     (Hyperswarm's own PeerDiscovery only retries a failed/empty lookup
     after a ~10-minute interval by default). Root cause not yet
     isolated -- tracked as a follow-up requiring direct dht.announce()/
     dht.lookup() instrumentation in the real 6-node Docker mesh.
   - All interop artifacts are committed regardless (both compose
     overlays, both new Node client scripts, run-relay-interop-test.sh)
     since scenario A already depends on and validates most of the
     plumbing scenario B needs.

Validation: cargo test --workspace (900 tests) + cargo clippy
--workspace --all-targets clean. Re-verified both existing Docker tests
(run-relay-test.sh, run-nat-test.sh) still pass after these changes.

Version bump: peeroxide-dht 1.6.0 -> 1.7.0 (minor, additive only --
new pub fns/enum variants, no breaking changes).
…discover it

Root cause of the relay-nodejs-interop failure (Node.js Hyperswarm peers
bridged by our Rust blind-relay server): run_relay_server only called
register_server(hash(pubkey)), a purely local marker telling the DHT
layer to handle inbound PEER_HANDSHAKE requests targeting our identity.
Nothing ever told the rest of the network the relay exists.

Node's dht.connect(pubkey) resolves candidates via a LOOKUP-style
findPeer() query for an announced record on hash(pubkey), not a raw
closest-node walk. Confirmed empirically: dht.findPeer(hash(relayPubkey))
returned zero replies against our relay, so Node's client never even
attempted a PEER_HANDSHAKE to it (relay-rust's logs showed zero
PEER_HANDSHAKE arrivals during a fresh isolated connect attempt), despite
raw FIND_NODE correctly returning the relay as a routing candidate.

Node's own dht.createServer()/server.listen() internally starts an
Announcer for the server's own target for exactly this reason. peeroxide
already has this pattern for Swarm servers (peer_discovery.rs's
self_announce on hash(pk)), but the standalone relay service bypasses
Swarm entirely and never announced itself.

Fix: after register_server, spawn a background task that calls
dht.announce(hash(pubkey), key_pair, relay_addresses) once at startup and
every ~10 minutes thereafter (jittered), mirroring
peer_discovery::do_refresh's self-announce cadence.

Verified: run-relay-interop-test.sh Scenario B (Node Hyperswarm peers
bridged by relay-rust) now passes. Re-ran Scenario A, run-relay-test.sh,
and run-nat-test.sh — all still green. Full workspace test suite (900
tests) and clippy clean.

peeroxide-dht: 1.7.0 -> 1.7.1 (bugfix, no API changes).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds Unreleased entries to libudx, peeroxide, peeroxide-dht, and
peeroxide-cli CHANGELOGs documenting the blind-relay server feature
(BlindRelayServer, relay_service, CLI surfaces), idle-timeout/teardown
hardening, the relay self-announce fix, and the two real bugs fixed
along the way (UDX stream-id collision, firewall-hook/relay-fast-path
ordering).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@eshork eshork left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀

eshork and others added 2 commits July 12, 2026 13:48
…ICY.md

RelayStats is a pub struct with pub atomic-counter fields, constructed
only via Default::default() and never struct-literal'd or destructured
outside the crate. Per VISIBILITY_POLICY.md's Event/Result role default
(and its own RelayStatsSnapshot counterpart, which already carries the
attribute), it should be #[non_exhaustive] so future counter fields can
be added additively without a breaking change. No valid Handle-role
exemption applies since its fields are public, not opaque.

peeroxide-dht: 1.7.1 -> 1.7.2 (non-breaking, adds a forward-compat
guard rail).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rkflow

VISIBILITY_POLICY.md existed but was never referenced from AGENTS.md or
CONTRIBUTING.md, so applying it depended on someone remembering to ask
for it explicitly (as happened on this PR's RelayStats fix). Closes that
gap:

- AGENTS.md: new subsection under the API Breaking Change Policy
  directing that any new pub type/function/module must be checked
  against VISIBILITY_POLICY.md in the same change, not as a follow-up.
- CONTRIBUTING.md: added an explicit PR-checklist item, plus a pointer
  from the API Stability section.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@eshork eshork merged commit 90b9125 into main Jul 12, 2026
5 checks passed
@eshork eshork deleted the relay_fix branch July 12, 2026 18:01
@github-actions github-actions Bot mentioned this pull request Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant